@boxpdf/html-writer 0.1.21 → 0.1.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -372,17 +372,20 @@ function number(value) {
372
372
  // src/visual-font.ts
373
373
  function visualFontAliases(pageNumber, fonts) {
374
374
  return new Map(
375
- fonts.filter((font) => font.format === "truetype" && !/(?:courier|^TTE)/i.test(font.family ?? "")).map((font) => [font.id, `boxpdf-${pageNumber}-${font.id}`])
375
+ fonts.filter(
376
+ (font) => (font.format === "truetype" || font.format === "opentype") && !/(?:courier|^TTE)/i.test(font.family ?? "")
377
+ ).map((font) => [font.id, `boxpdf-${pageNumber}-${font.id}`])
376
378
  );
377
379
  }
378
380
  function visualFontFace(font, aliases) {
379
- if (font.format !== "truetype") return "";
381
+ if (font.format !== "truetype" && font.format !== "opentype") return "";
380
382
  const alias = aliases.get(font.id);
381
383
  if (!alias) return "";
382
384
  const styles2 = visualFontStyles(font.family, alias).filter(
383
385
  (style) => !style.startsWith("font-family:")
384
386
  );
385
- return `@font-face{font-family:${alias};src:url(data:font/ttf;base64,${base64(font.data)}) format("truetype");${styles2.join(";")}}`;
387
+ const mime = font.format === "opentype" ? "font/otf" : "font/ttf";
388
+ return `@font-face{font-family:${alias};src:url(data:${mime};base64,${base64(font.data)}) format("${font.format}");${styles2.join(";")}}`;
386
389
  }
387
390
  function visualFontStyles(fontFamily, alias) {
388
391
  const normalized = fontFamily?.toLowerCase() ?? "";
@@ -464,7 +467,9 @@ function vectorMedia(page, imageOptions) {
464
467
  );
465
468
  const aliases = visualFontAliases(page.number, page.fonts ?? []);
466
469
  const visualCodeFonts = new Set(
467
- (page.fonts ?? []).filter((font) => font.format === "truetype" && font.visualCodeMapping).map((font) => font.id)
470
+ (page.fonts ?? []).filter(
471
+ (font) => (font.format === "truetype" || font.format === "opentype") && font.visualCodeMapping
472
+ ).map((font) => font.id)
468
473
  );
469
474
  return components.map((component, componentIndex) => {
470
475
  const bounds2 = component.bounds;
@@ -1317,7 +1322,8 @@ async function writeHtmlDocument(pages, write, options = {}) {
1317
1322
  );
1318
1323
  options.onSemanticStats?.(stats);
1319
1324
  } else {
1320
- for await (const page of pages) await writePage(page, write, options);
1325
+ const documentFonts = { entries: [] };
1326
+ for await (const page of pages) await writePositionedPage(page, write, options, documentFonts);
1321
1327
  }
1322
1328
  await write("</main>");
1323
1329
  if (includeDocument) await write("</body></html>");
@@ -1360,7 +1366,7 @@ async function pageToHtml(page, options = {}) {
1360
1366
  );
1361
1367
  return output;
1362
1368
  }
1363
- async function writePositionedPage(page, write, options) {
1369
+ async function writePositionedPage(page, write, options, documentFonts) {
1364
1370
  const imageOptions = resolveImageOptions("visual", options);
1365
1371
  const visualImages = await prepareVisualImages(page, imageOptions, options.onImage);
1366
1372
  const visualSpans = coalesceVisualSpans(page.visualSpans ?? page.spans);
@@ -1371,14 +1377,18 @@ async function writePositionedPage(page, write, options) {
1371
1377
  await write(
1372
1378
  `<section class="pdf-page pdf-page--visual pdf-page--positioned" data-page="${page.number}" data-rotate="${page.rotate}" style="width:${number3(displayWidth)}pt;height:${number3(displayHeight)}pt">`
1373
1379
  );
1374
- const fontAliases = visualFontAliases(page.number, page.fonts ?? []);
1380
+ const { aliases: fontAliases, fontsToEmit } = visualPageFonts(
1381
+ page.number,
1382
+ page.fonts ?? [],
1383
+ documentFonts
1384
+ );
1375
1385
  const type3Fonts = new Map(
1376
1386
  (page.fonts ?? []).filter((font) => font.format === "type3").map((font) => [font.id, font])
1377
1387
  );
1378
1388
  const textClasses = options.includeStyles ?? true ? visualTextClasses(page.number, visualSpans, fontAliases) : void 0;
1379
- if ((options.includeStyles ?? true) && page.fonts?.length) {
1389
+ if ((options.includeStyles ?? true) && fontsToEmit.length) {
1380
1390
  await write(
1381
- `<style>${page.fonts.map((font) => visualFontFace(font, fontAliases)).join("")}</style>`
1391
+ `<style>${fontsToEmit.map((font) => visualFontFace(font, fontAliases)).join("")}</style>`
1382
1392
  );
1383
1393
  }
1384
1394
  if (textClasses?.css) await write(`<style>${textClasses.css}</style>`);
@@ -1445,6 +1455,30 @@ async function writePositionedPage(page, write, options) {
1445
1455
  }
1446
1456
  await write("</div></section>");
1447
1457
  }
1458
+ function visualPageFonts(pageNumber, fonts, documentFonts) {
1459
+ const aliases = visualFontAliases(pageNumber, fonts);
1460
+ if (!documentFonts) return { aliases, fontsToEmit: fonts };
1461
+ const fontsToEmit = [];
1462
+ for (const font of fonts) {
1463
+ if (!aliases.has(font.id) || font.format === "type3") continue;
1464
+ const existing = documentFonts.entries.find(
1465
+ (entry) => entry.format === font.format && equalBytes(entry.data, font.data)
1466
+ );
1467
+ if (existing) {
1468
+ aliases.set(font.id, existing.alias);
1469
+ continue;
1470
+ }
1471
+ const alias = `boxpdf-document-font-${documentFonts.entries.length + 1}`;
1472
+ aliases.set(font.id, alias);
1473
+ documentFonts.entries.push({ alias, data: font.data, format: font.format });
1474
+ fontsToEmit.push(font);
1475
+ }
1476
+ return { aliases, fontsToEmit };
1477
+ }
1478
+ function equalBytes(left, right) {
1479
+ if (left.length !== right.length) return false;
1480
+ return left.every((value, index) => value === right[index]);
1481
+ }
1448
1482
  function coalesceVisualSpans(spans) {
1449
1483
  const output = [];
1450
1484
  for (const span of spans) {
@@ -1470,11 +1504,14 @@ function canCoalesceVisualSpans(left, right) {
1470
1504
  if (left.direction !== "ltr" || right.direction !== "ltr") return false;
1471
1505
  if (/guardian/i.test(left.fontFamily ?? "")) return false;
1472
1506
  if (left.glyphCodes || right.glyphCodes) return false;
1507
+ if (left.fontAssetId || right.fontAssetId) return false;
1473
1508
  if (!sameVisualTextState(left, right)) return false;
1474
1509
  const tolerance = Math.max(0.02, left.fontSize * 0.015);
1475
1510
  if (Math.abs(left.bounds.y - right.bounds.y) > tolerance) return false;
1476
1511
  const gap = right.bounds.x - (left.bounds.x + left.bounds.width);
1477
- return !right.hasLeadingSpace && gap >= -tolerance && gap <= tolerance;
1512
+ if (right.hasLeadingSpace) return false;
1513
+ if (gap >= -tolerance && gap <= tolerance) return true;
1514
+ return right.textAdjustmentBefore !== void 0 && right.textAdjustmentBefore < 0 && Math.abs(right.textAdjustmentBefore - gap) <= 1e-3;
1478
1515
  }
1479
1516
  function sameVisualTextState(left, right) {
1480
1517
  return Math.abs(left.fontSize - right.fontSize) <= 1e-3 && left.fontName === right.fontName && left.fontFamily === right.fontFamily && left.fontAssetId === right.fontAssetId && left.color === right.color && left.fillOpacity === right.fillOpacity && left.strokeColor === right.strokeColor && left.strokeWidth === right.strokeWidth && left.strokeOpacity === right.strokeOpacity && left.renderingMode === right.renderingMode && sameTransform(left.transform, right.transform);
@@ -1515,6 +1552,13 @@ function visualTextLine(spans, startIndex, styleClasses, pageHeight, fontAliases
1515
1552
  if (endIndex === startIndex) return void 0;
1516
1553
  const baseline = pageHeight - first.bounds.y;
1517
1554
  const lineSpans = spans.slice(startIndex, endIndex + 1);
1555
+ const dxRun = visualDxTextRun(lineSpans, fontAliases);
1556
+ if (dxRun) {
1557
+ return {
1558
+ html: `<text class="${className}" x="${number3(first.bounds.x)}" y="${number3(baseline)}" dx="${dxRun.offsets.map(number3).join(" ")}">${escapeHtml4(dxRun.text)}</text>`,
1559
+ endIndex
1560
+ };
1561
+ }
1518
1562
  const content = lineSpans.map(
1519
1563
  (span, index) => visualTextTspan(span, index > 0 ? textSpanGap(lineSpans[index - 1], span) : void 0)
1520
1564
  ).join("");
@@ -1523,6 +1567,32 @@ function visualTextLine(spans, startIndex, styleClasses, pageHeight, fontAliases
1523
1567
  endIndex
1524
1568
  };
1525
1569
  }
1570
+ function visualDxTextRun(spans, fontAliases) {
1571
+ const first = spans[0];
1572
+ if (!first?.fontAssetId || !fontAliases.has(first.fontAssetId) || spans.length < 2)
1573
+ return void 0;
1574
+ let text = first.text;
1575
+ const offsets = characterOffsets(first, 0);
1576
+ for (let index = 1; index < spans.length; index += 1) {
1577
+ const previous = spans[index - 1];
1578
+ const current = spans[index];
1579
+ if (!previous || !current || current.fontAssetId !== first.fontAssetId) return void 0;
1580
+ const characters = [...current.text];
1581
+ if (characters.length === 0 || previous.naturalWidth === void 0) return void 0;
1582
+ const gap = current.bounds.x - (previous.bounds.x + previous.bounds.width);
1583
+ const trailingSpacing = (previous.characterSpacing ?? 0) + (previous.text.endsWith(" ") ? previous.wordSpacing ?? 0 : 0);
1584
+ offsets.push(...characterOffsets(current, gap + trailingSpacing));
1585
+ text += current.text;
1586
+ }
1587
+ while (offsets.at(-1) === 0) offsets.pop();
1588
+ return offsets.length > 0 ? { text, offsets } : void 0;
1589
+ }
1590
+ function characterOffsets(span, first) {
1591
+ const characters = [...span.text];
1592
+ return characters.map(
1593
+ (_, index) => index === 0 ? first : (span.characterSpacing ?? 0) + (characters[index - 1] === " " ? span.wordSpacing ?? 0 : 0)
1594
+ );
1595
+ }
1526
1596
  function visualTextTspan(span, dx) {
1527
1597
  const extent = span.bounds.width;
1528
1598
  const offset = dx === void 0 || number3(dx) === "0" ? "" : ` dx="${number3(dx)}"`;